<?php
/**
 * Zend Framework
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://framework.zend.com/license/new-bsd
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@zend.com so we can send you a copy immediately.
 *
 * @category   Zend
 * @package    Zend_Ldap
 * @copyright  Copyright (c) 2005-2008 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 * @version    $Id: Helper.php 10 2008-11-08 17:17:02Z Stefan.Gehrig.HN $
 */

/** 
 * Zend_Ldap_Exception
 */
require_once 'Zend/Ldap/Exception.php';

/**
 * Zend_Ldap_Helper is a collection of useful LDAP related functions.
 *
 * @category   Zend
 * @package    Zend_Ldap
 * @copyright  Copyright (c) 2005-2008 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 */
class Zend_Ldap_Helper
{
	const PASSWORD_HASH_MD5='md5';
	const PASSWORD_HASH_SHA='sha1';
	
	const RDN_ATTR_CASEFOLD_NONE='none';
	const RDN_ATTR_CASEFOLD_UPPER='upper';
	const RDN_ATTR_CASEFOLD_LOWER='lower';
	
	/**
	 * Sets a LDAP attribute.
	 *
	 * @param  array $data
	 * @param  string $attribName
	 * @param  mixed $value
	 * @param  bool $append
	 * @param  string $encoding
	 * @return void
	 */
	public static function setAttribute(array &$data, $attribName, $value, 
		$append=false, $encoding='UTF-8')
	{
		$attribName=mb_strtolower($attribName, $encoding);
		$valArray=array();
		if (is_array($value) || ($value instanceof Traversable))
		{
			foreach ($value as $v) 
			{
				$v=self::_valueToLdap($v);
				if (!is_null($v)) $valArray[]=$v;
			}
		}
		else if (!is_null($value)) 
		{
			$value=self::_valueToLdap($value);
			if (!is_null($value)) $valArray[]=$value;
		}
		
		if ($append===true && isset($data[$attribName]))
		{
			if (is_string($data[$attribName])) $data[$attribName]=array($data[$attribName]);
			$data[$attribName]=array_merge($data[$attribName], $valArray);
		}
		else
		{
			$data[$attribName]=$valArray;
		}
	}
	
	/**
	 * Gets a LDAP attribute.
	 *
	 * @param  array $data
	 * @param  string $attribName
	 * @param  integer $index
	 * @param  string $encoding
	 * @return array|mixed
	 */
	public static function getAttribute(array $data, $attribName, $index=null, $encoding='UTF-8')
	{
		$attribName=mb_strtolower($attribName, $encoding);
        if (is_null($index)) {
		    if (!isset($data[$attribName])) return array();
			$retArray=array();
			foreach ($data[$attribName] as $v)
			{
				$retArray[]=self::_valueFromLdap($v);
			}
            return $retArray;
		} else if (is_int($index)) {
		    if (!isset($data[$attribName])) {
                return null;
		    } else if ($index>=0 && $index<count($data[$attribName])) {
		      return self::_valueFromLdap($data[$attribName][$index]);
		    } else {
                return null;
		    }
		}
		return null;
	}
	
	/**
	 * Remove given values from a LDAP attribute
	 *
	 * @param array $data
	 * @param string $attribName
	 * @param mixed|array $value
	 * @param string $encoding
	 * @return void
	 */
	public static function removeFromAttribute(array &$data, $attribName, $value, $encoding='UTF-8')
	{
	    $attribName=mb_strtolower($attribName, $encoding);
        if (!isset($data[$attribName])) return;
	    
        $valArray=array();
        if (is_array($value) || ($value instanceof Traversable))
        {
            foreach ($value as $v) 
            {
                $v=self::_valueToLdap($v);
                if (!is_null($v)) $valArray[]=$v;
            }
        }
        else if (!is_null($value)) 
        {
            $value=self::_valueToLdap($value);
            if (!is_null($value)) $valArray[]=$value;
        }
    
        $resultArray=$data[$attribName];
        foreach ($valArray as $rv) {
            $keys=array_keys($resultArray, $rv);
            foreach ($keys as $k) {
                unset($resultArray[$k]);
            }
        }
        $resultArray=array_values($resultArray);
        $data[$attribName]=$resultArray;
	}
	
	private static function _valueToLdap($value)
	{
		if (is_string($value)) return $value;
		else if (is_int($value) || is_float($value)) return (string)$value;
		else if (is_bool($value)) return ($value===true) ? 'TRUE' : 'FALSE';
		else if (is_object($value) || is_array($value)) return serialize($value);
		else if (is_resource($value) && get_resource_type($value)==='stream') 
			return stream_get_contents($value);
		else return null;
	}
	
    private static function _valueFromLdap($value)
    {
        $value=(string)$value;
        if ($value==='TRUE') return true;
        else if ($value==='FALSE') return false;
        else return $value;
    }
	
	/**
	 * Converts a PHP data type into its LDAP representation
	 *
	 * @param mixed $value
	 * @return string|null - null if the PHP data type cannot be converted.
	 */
	public static function convertToLdapValue($value)
	{
	    return self::_valueToLdap($value);
	}
	
    /**
     * Converts an LDAP value into its PHP data type
     *
     * @param string $value
     * @return mixed
     */
    public static function convertFromLdapValue($value)
    {
        return self::_valueFromLdap($value);
    }
    
    /**
     * Converts a timestamp into its LDAP date/time representation
     *
     * @param integer $value
     * @param boolean $utc
     * @return string|null - null if the value cannot be converted.
     */
    public static function convertToLdapDateTimeValue($value, $utc=false)
    {
        return self::_valueToLdapDateTime($value, $utc);
    }
    
    /**
     * Converts LDAP date/time representation into a timestamp
     *
     * @param string $value
     * @return integer|null - null if the value cannot be converted.
     */
    public static function convertFromLdapDateTimeValue($value)
    {
        return self::_valueFromLdapDateTime($value);
    }
	
	/**
	 * Sets a LDAP password.
	 *
	 * @param  array $data
	 * @param  string $password
	 * @param  string $hashType
	 * @param  string $attribName
	 * @param  string $encoding
	 * @return null
	 */
	public static function setPassword(array &$data, $password, $hashType=self::PASSWORD_HASH_MD5,
		$attribName='userPassword', $encoding='UTF-8')
	{
		$hash=self::createPassword($password, $hashType);
		self::setAttribute($data, $attribName, $hash, false, $encoding);
	}
	
	/**
     * Creates a LDAP password.
     *
     * @param  string $password
     * @param  string $hashType
     * @return string
     */
	public static function createPassword($password, $hashType=self::PASSWORD_HASH_MD5)
	{
	    switch ($hashType) {
            case self::PASSWORD_HASH_SHA:
                $rawHash=sha1($password, true);
                $method='{SHA}';
                break;
            case self::PASSWORD_HASH_MD5:
            default:
                $rawHash=md5($password, true);
                $method='{MD5}';
                break;
	    }
        return $method . base64_encode($rawHash);
	}
	
    /**
     * Sets a LDAP date/time attribute.
     *
     * @param  array $data
     * @param  string $attribName
     * @param  integer|array $value
     * @param  bool $utc
     * @param  bool $append
     * @param  string $encoding
     * @return null
     */
    public static function setDateTimeAttribute(array &$data, $attribName, $value, $utc=false, 
        $append=false, $encoding='UTF-8')
    {
        $convertedValues=array();
        if (is_array($value) || ($value instanceof Traversable))
        {
            foreach ($value as $v) {
                $v=self::_valueToLdapDateTime($v, $utc);
                if (!is_null($v)) $convertedValues[]=$v;
            }
        }
        else if (!is_null($value)) {
            $value=self::_valueToLdapDateTime($value, $utc);
            if (!is_null($value)) $convertedValues[]=$value;
        }
        self::setAttribute($data, $attribName, $convertedValues, $append, $encoding);
    }
    
    private static function _valueToLdapDateTime($value, $utc)
    {
        if (is_int($value)) {
            if ($utc===true) return gmdate('YmdHis', $value) . 'Z';
            else return date('YmdHisO', $value);
        }
        else return null;
    }
    
    /**
     * Gets a LDAP date/time attribute.
     *
     * @param  array $data
     * @param  string $attribName
     * @param  integer $index
     * @param  string $encoding
     * @return array|integer
     */
    public static function getDateTimeAttribute(array $data, $attribName, $index=null, $encoding='UTF-8')
    {
        $values=self::getAttribute($data, $attribName, $index, $encoding);
        if (is_array($values)) {
            for ($i=0; $i<count($values); $i++) {
                $newVal=self::_valueFromLdapDateTime($values[$i]);
                if (!is_null($newVal)) $values[$i]=$newVal;
            }
        }
        else $values=self::_valueFromLdapDateTime($values);
        return $values;
    }
    
    private static function _valueFromLdapDateTime($value)
    {
        $matches=array();
        if (preg_match('/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})([+-]\d{4}|Z)$/', $value, $matches)) {
            $year=$matches[1];
            $month=$matches[2];
            $day=$matches[3];
            $hour=$matches[4];
            $minute=$matches[5];
            $second=$matches[6];
            $timezone=$matches[7];
            $date=gmmktime($hour, $minute, $second, $month, $day, $year);
            if ($timezone!=='Z') {
                $tzDirection=substr($timezone, 0, 1);
                $tzOffsetHour=substr($timezone, 1, 2);
                $tzOffsetMinute=substr($timezone, 3, 2);
                $tzOffset=($tzOffsetHour*60*60) + ($tzOffsetMinute*60);
                if ($tzDirection=='+') $date-=$tzOffset;
                else if ($tzDirection=='-') $date+=$tzOffset;
            }
            return $date;
        }
        else return null;
    }
    
    /**
     * Creates an array containing all parts of the given DN.
     *
     * Array will be of type
     * array(
     *      array("cn", "name1"),
     *      array("cn", "name2"),
     *      array("dc", "example"),
     *      array("dc", "org")
     * )
     * for a DN of cn=name1,cn=name2,dc=example,dc=org.
     * 
     * @uses   Zend_Ldap::explodeDn()
     * @param  string $dn
     * @param  array  $keys An optional array to receive DN keys (e.g. CN, OU, DC, ...)
     * @param  array  $vals An optional array to receive DN values
     * @return array
     * @throws Zend_Ldap_Exception
     */
    public static function explodeDn($dn, array &$keys = null, array &$vals = null)
    {
        /** 
         * Zend_Ldap 
         */
        require_once 'Zend/Ldap.php';
        
        $k=array();
        $v=array();
        if (!Zend_Ldap::explodeDn($dn, $k, $v)) {
            throw new Zend_Ldap_Exception(null, 'DN is malformed.', Zend_Ldap_Exception::LDAP_OTHER);
        }
        $ret=array();
        for ($i=0; $i<count($k); $i++) {
            $ret[]=array($k[$i], $v[$i]);
        }
        if (!is_null($keys)) $keys=$k;
        if (!is_null($vals)) $vals=$v;
        return $ret;
    }
    
    /**
     * Returns a DN part in the form $attribute=$value
     * 
     * This method supports the creation of multi-valued RDNs
     * $part must contain an even number of elemets.
     *
     * @param  array $attribute
     * @param  string $caseFold
     * @param  string $encoding
     * @return string
     * @throws Zend_Ldap_Exception
     */
    public static function implodeRdn(array $part, $caseFold=self::RDN_ATTR_CASEFOLD_NONE, $encoding='UTF-8')
    {
        $part=array_values($part);
        $count=count($part);
        if (($count % 2)>0) {
            throw new Zend_Ldap_Exception(null, 'RDN array is malformed.', Zend_Ldap_Exception::LDAP_OTHER);
        }
        else if ($count==2) {
            if ($caseFold==self::RDN_ATTR_CASEFOLD_LOWER) $part[0]=mb_strtolower($part[0], $encoding);
            else if ($caseFold==self::RDN_ATTR_CASEFOLD_UPPER) $part[0]=mb_strtoupper($part[0], $encoding);
            return implode('=', $part); 
        }
        $multiParts=array();
        for ($i=0; $i<$count; $i+=2) {
            $k=$part[$i];
            if ($caseFold==self::RDN_ATTR_CASEFOLD_LOWER) $k=mb_strtolower($k, $encoding);
            else if ($caseFold==self::RDN_ATTR_CASEFOLD_UPPER) $k=mb_strtoupper($k, $encoding);
            $v=$part[$i+1];
            if (isset($multiParts[$k])) {
                throw new Zend_Ldap_Exception(null, 'RDN array is malformed. Attribute \'' + 
                    $k + '\' appears more than once', 
                    Zend_Ldap_Exception::LDAP_OTHER);
            }
            $multiParts[$k]=implode('=', array($k, $v));
        }
        ksort($multiParts, SORT_STRING);
        return implode('+', $multiParts);
    }
    
    /**
     * Implodes an array in the form delivered by {@link explodeDn()}
     * to a DN string.
     * 
     * $dnArray must be of type
     * array(
     *      array("cn", "name1"),
     *      array("cn", "name2"),
     *      array("dc", "example"),
     *      array("dc", "org")
     * )
     *
     * @param  array $dnArray
     * @param  string $caseFold
     * @param  string $separator
     * @param  string $encoding
     * @return string
     * @throws Zend_Ldap_Exception
     */
    public static function implodeDn(array $dnArray, $caseFold=self::RDN_ATTR_CASEFOLD_NONE, 
        $separator=',', $encoding='UTF-8')
    {
        $parts=array();
        foreach ($dnArray as $p) {
            $parts[]=self::implodeRdn($p, $caseFold, $encoding);
        }
        return implode($separator, $parts);        
    }
    
    /**
     * Checks if given $childDn is beneath $parentDn subtree.
     *
     * @param  string $childDn
     * @param  string $parentDn
     * @return boolean
     */
    public static function isChildOf($childDn, $parentDn)
    {
        try {
            $cdn=self::explodeDn($childDn);
            $pdn=self::explodeDn($parentDn);
        }
        catch (Zend_Ldap_Exception $e) {
            return false;
        }
        $startIndex=count($cdn)-count($pdn);
        if ($startIndex<0) return false;
        for ($i=0; $i<count($pdn); $i++) {
            if ($cdn[$i+$startIndex]!=$pdn[$i]) return false;
        }
        return true;
    }
    
    /**
     * Escapes the given VALUES according to RFC 2254 so that they can be safely used in LDAP filters.
     *
     * Any control characters with an ACII code < 32 as well as the characters with special meaning in
     * LDAP filters "*", "(", ")", and "\" (the backslash) are converted into the representation of a
     * backslash followed by two hex digits representing the hexadecimal value of the character.
     * @see Net_LDAP2_Util::escape_filter_value() from Benedikt Hallinger <beni@php.net>
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>
     *
     * @param  string|array $values Array of values to escape
     * @return array Array $values, but escaped
     */
    public static function escapeFilterValue($values=array())
    {
        if (!is_array($values)) $values=array($values);
        foreach ($values as $key => $val) {
            // Escaping of filter meta characters
            $val=str_replace('\\', '\5c', $val);
            $val=str_replace('*',  '\2a', $val);
            $val=str_replace('(',  '\28', $val);
            $val=str_replace(')',  '\29', $val);
            // ASCII < 32 escaping
            $val=self::ascToHex32($val);
            if (null===$val) $val='\0';  // apply escaped "null" if string is empty
            $values[$key]=$val;
        }
        return (count($values)==1) ? $values[0] : $values;
    }

    /**
     * Undoes the conversion done by {@link escapeFilterValues()}.
     *
     * Converts any sequences of a backslash followed by two hex digits into the corresponding character.
     * @see Net_LDAP2_Util::escape_filter_value() from Benedikt Hallinger <beni@php.net>
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>
     *
     * @param  string|array $values Array of values to escape
     * @return array Array $values, but unescaped
     */
    public static function unescapeFilterValue($values=array())
    {
        if (!is_array($values)) $values=array($values);
        foreach ($values as $key => $value) {
            // Translate hex code into ascii
            $values[$key]=self::hex32ToAsc($value);
        }
        return (count($values)==1) ? $values[0] : $values;
    }
    
    /**
     * Escapes a DN value according to RFC 2253
     *
     * Escapes the given VALUES according to RFC 2253 so that they can be safely used in LDAP DNs.
     * The characters ",", "+", """, "\", "<", ">", ";", "#", "=" with a special meaning in RFC 2252
     * are preceeded by ba backslash. Control characters with an ASCII code < 32 are represented as \hexpair.
     * Finally all leading and trailing spaces are converted to sequences of \20.
     * @see Net_LDAP2_Util::escape_dn_value() from Benedikt Hallinger <beni@php.net>
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>
     *
     * @param  string|array $values An array containing the DN values that should be escaped
     * @return array The array $values, but escaped
     */
    public static function escapeDnValue($values=array())
    {
        if (!is_array($values)) $values=array($values);
        foreach ($values as $key => $val) {
            // Escaping of filter meta characters
            $val=str_replace('\\', '\\\\', $val);
            $val=str_replace(',', '\,', $val);
            $val=str_replace('+', '\+', $val);
            $val=str_replace('"', '\"', $val);
            $val=str_replace('<', '\<', $val);
            $val=str_replace('>', '\>', $val);
            $val=str_replace(';', '\;', $val);
            $val=str_replace('#', '\#', $val);
            $val=str_replace('=', '\=', $val);
            $val=self::ascToHex32($val);

            // Convert all leading and trailing spaces to sequences of \20.
            if (preg_match('/^(\s*)(.+?)(\s*)$/', $val, $matches)) {
                $val=$matches[2];
                for ($i=0; $i<strlen($matches[1]); $i++) {
                    $val='\20'.$val;
                }
                for ($i=0; $i<strlen($matches[3]); $i++) {
                    $val=$val.'\20';
                }
            }
            if (null===$val) $val='\0';  // apply escaped "null" if string is empty
            $values[$key] = $val;
        }
        return (count($values)==1) ? $values[0] : $values;
    }

    /**
     * Undoes the conversion done by {@link escapeDnValues()}.
     *
     * Any escape sequence starting with a baskslash - hexpair or special character -
     * will be transformed back to the corresponding character.
     * @see Net_LDAP2_Util::escape_dn_value() from Benedikt Hallinger <beni@php.net>
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>
     *
     * @param  string|array $values Array of DN Values
     * @return array Same as $values, but unescaped
     */
    public static function unescapeDnValue($values=array())
    {
        if (!is_array($values)) $values=array($values);
        foreach ($values as $key => $val) {
            // strip slashes from special chars
            $val=str_replace('\\\\', '\\', $val);
            $val=str_replace('\,', ',', $val);
            $val=str_replace('\+', '+', $val);
            $val=str_replace('\"', '"', $val);
            $val=str_replace('\<', '<', $val);
            $val=str_replace('\>', '>', $val);
            $val=str_replace('\;', ';', $val);
            $val=str_replace('\#', '#', $val);
            $val=str_replace('\=', '=', $val);
            $values[$key] = self::hex32ToAsc($val);
        }
        return (count($values)==1) ? $values[0] : $values;
    }
    
    /**
     * Converts all ASCII chars < 32 to "\HEX"
     *
     * @see Net_LDAP2_Util::asc2hex32() from Benedikt Hallinger <beni@php.net>
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>
     * 
     * @param  string $string String to convert
     * @return string
     */
    public static function ascToHex32($string)
    {
        for ($i=0; $i<strlen($string); $i++) {
            $char=substr($string, $i, 1);
            if (ord($char)<32) {
                $hex=dechex(ord($char));
                if (strlen($hex)==1) $hex='0' . $hex;
                $string=str_replace($char, '\\' . $hex, $string);
            }
        }
        return $string;
    }

    /**
     * Converts all Hex expressions ("\HEX") to their original ASCII characters
     *
     * @see Net_LDAP2_Util::hex2asc() from Benedikt Hallinger <beni@php.net>,
     * heavily based on work from DavidSmith@byu.net
     * @link http://pear.php.net/package/Net_LDAP2
     * @author Benedikt Hallinger <beni@php.net>, heavily based on work from DavidSmith@byu.net
     * 
     * @param  string $string String to convert
     * @return string
     */
    public static function hex32ToAsc($string)
    {
        $string = preg_replace("/\\\([0-9A-Fa-f]{2})/e", "''.chr(hexdec('\\1')).''", $string);
        return $string;
    }
}